29 — 汹涌的海
简介
现在我们知道了如何使用 shader 并绘制一些图案,让我们好好利用它来创建一个汹涌的海。
我们将为波浪制作动画,并通过 debug 面板来控制这些参数。
Setup
目前,我们只有一个使用 MeshBasicMaterial 的旋转过的平面。geometry 有 128x128 的细分。我们将对顶点进行动画来得到波浪,而这需要相当多的顶点。128x128 可能不够,但如果需要,我们会增大这个值。
基础
让我们把 material 替换为一个 ShaderMaterial:
const waterMaterial = new THREE.ShaderMaterial()
我们的项目配置已经支持 GLSL 文件,但我们需要创建这些文件。
在 /src/shaders/water/vertex.glsl 中创建 vertex shader:
void main()
{
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
vec4 viewPosition = viewMatrix * modelPosition;
vec4 projectedPosition = projectionMatrix * viewPosition;
gl_Position = projectedPosition;
} 现在在 /src/shaders/water/fragment.glsl 中创建 fragment shader:
void main()
{
gl_FragColor = vec4(0.5, 0.8, 1.0, 1.0);
} Three.js 的 WebGLRenderer 要求我们在正确的色彩空间中输出颜色(默认是 sRGB)。这会改善输出的颜色,但这也意味着我们需要在 fragment shader 的末尾添加一些代码,以便将当前的 gl_FragColor 转换为 sRGB 版本。
幸运的是,Three.js 已经提供了这段代码,我们只需在 fragment shader 的最末尾添加下面这一行:
void main()
{
// ...
#include <colorspace_fragment>
} 你将在课程后续部分了解到这个 #include 是什么。
最后,在你的 script 中导入这些 shader,并在一个 ShaderMaterial 中使用它们:
// ...
import waterVertexShader from './shaders/water/vertex.glsl'
import waterFragmentShader from './shaders/water/fragment.glsl'
// ...
const waterMaterial = new THREE.ShaderMaterial({
vertexShader: waterVertexShader,
fragmentShader: waterFragmentShader
})
你应该得到一个蓝色的平面。如果没有,请检查日志。
如果你是凭记忆完成这一切的,恭喜你,你是个天才。如果不是,这完全正常,你只是需要时间。
大波浪
我们从大波浪开始,以便快速获得显著的效果。还有什么比正弦波更适合用来创建波浪呢?
在 vertex shader 中,让我们基于 x 用 sin(...) 来移动 modelPosition 的 y 值:
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
modelPosition.y += sin(modelPosition.x);
位移和频率应该太高了。与其在 shader 中直接乘以那些凭空出现的数值,我们将使用 uniforms 来更好地控制它们。
让我们从高度(elevation)开始。
高度(Elevation)
向 ShaderMaterial 添加一个 uBigWavesElevation uniform:
const waterMaterial = new THREE.ShaderMaterial({
vertexShader: waterVertexShader,
fragmentShader: waterFragmentShader,
uniforms:
{
uBigWavesElevation: { value: 0.2 }
}
}) 我们现在可以在 vertex shader 中获取并使用 uBigWavesElevation uniform:
uniform float uBigWavesElevation;
void main()
{
// ...
modelPosition.y += sin(modelPosition.x) * uBigWavesElevation;
// ...
}
与其直接更新 y 属性,我们应该使用一个名为 elevation 的变量。这在稍后我们给这些波浪着色时会派上用场:
uniform float uBigWavesElevation;
void main()
{
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
// Elevation
float elevation = sin(modelPosition.x) * uBigWavesElevation;
modelPosition.y += elevation;
// ...
} 因为现在 elevation 在 JavaScript 中处理,我们可以把它添加到我们的 lil-gui 中:
gui.add(waterMaterial.uniforms.uBigWavesElevation, 'value').min(0).max(1).step(0.001).name('uBigWavesElevation')
频率(Frequency)
现在我们来处理频率。目前,波浪的高度只在 x 轴上变化,但如果能同时控制 x 和 z 轴会更好。
创建一个带有 Vector2 的 uBigWavesFrequency uniform:
const waterMaterial = new THREE.ShaderMaterial({
vertexShader: waterVertexShader,
fragmentShader: waterFragmentShader,
uniforms:
{
uBigWavesElevation: { value: 0.2 },
uBigWavesFrequency: { value: new THREE.Vector2(4, 1.5) }
}
}) 在 vertex shader 中获取这个 uniform ——注意,它是一个 vec2—— 并先只把 x 属性应用到 sin(...) 中:
// ...
uniform vec2 uBigWavesFrequency;
void main()
{
// ...
float elevation = sin(modelPosition.x * uBigWavesFrequency.x) * uBigWavesElevation;
// ...
}
因为频率更高,这应该会产生更多的波浪。
让我们使用 uBigWavesFrequency 的第二个值(y)来控制 z 轴上的波浪。我们可以通过将第一个 sin(...) 乘以另一个 sin(...) 来实现:
float elevation = sin(modelPosition.x * uBigWavesFrequency.x) * sin(modelPosition.z * uBigWavesFrequency.y) * uBigWavesElevation;
你也应该在 z 轴上得到波浪。
现在我们可以把这些 x 和 y 属性添加到我们的 lil-gui 中:
gui.add(waterMaterial.uniforms.uBigWavesFrequency.value, 'x').min(0).max(10).step(0.001).name('uBigWavesFrequencyX')
gui.add(waterMaterial.uniforms.uBigWavesFrequency.value, 'y').min(0).max(10).step(0.001).name('uBigWavesFrequencyY')
动画(Animate)
让我们给这些大波浪添加动画。我们将像上一课那样使用经过的时间(elapsed time)来偏移 sin(...) 中的值,从而创建动画。
首先,在 ShaderMaterial 中创建一个 uTime uniform:
const waterMaterial = new THREE.ShaderMaterial({
// ...
uniforms:
{
uTime: { value: 0 },
// ...
}
}) 然后,在 tick function 中更新这个值:
const clock = new THREE.Clock()
const tick = () =>
{
const elapsedTime = clock.getElapsedTime()
// Water
waterMaterial.uniforms.uTime.value = elapsedTime
// ...
} 在 vertex shader 中,在两个 sin(...) 函数中获取并使用 uTime:
uniform float uTime;
// ...
void main()
{
// ...
float elevation = sin(modelPosition.x * uBigWavesFrequency.x + uTime) * sin(modelPosition.z * uBigWavesFrequency.y + uTime) * uBigWavesElevation;
// ...
}
你现在得到了有动画的波浪。虽然这个速度对汹涌的海来说还可以,但如果能控制它会更好。
让我们创建一个 uBigWavesSpeed uniform 并用它乘以 uTime。为了更简单,我们将使用 float,但如果你想分别控制两个轴的速度,可以使用 vec2。
在 ShaderMaterial 中创建 uBigWavesSpeed uniform 并添加 tweak:
const waterMaterial = new THREE.ShaderMaterial({
// ...
uniforms:
{
// ...
uBigWavesSpeed: { value: 0.75 }
}
})
// ...
gui.add(waterMaterial.uniforms.uBigWavesSpeed, 'value').min(0).max(4).step(0.001).name('uBigWavesSpeed') 在 vertex shader 中获取 uBigWavesSpeed uniform,并在两个 sin(...) 函数中用 uTime 乘以它:
// ...
uniform float uBigWavesSpeed;
void main()
{
// ...
float elevation = sin(modelPosition.x * uBigWavesFrequency.x + uTime * uBigWavesSpeed) * sin(modelPosition.z * uBigWavesFrequency.y + uTime * uBigWavesSpeed) * uBigWavesElevation;
// ...
}
我们的 elevation 公式太长了。不要犹豫,可以用变量或简单的换行稍微重构一下:
float elevation = sin(modelPosition.x * uBigWavesFrequency.x + uTime * uBigWavesSpeed) *
sin(modelPosition.z * uBigWavesFrequency.y + uTime * uBigWavesSpeed) *
uBigWavesElevation;
颜色
我们的波浪开始看起来很棒了,但那种统一的蓝色并没有什么帮助。
让我们生成两种颜色,一种用于深处(depth),一种用于表面(surface)。如果你还记得,往 lil-gui 中添加 Three.js 颜色有点复杂。
首先,我们需要在 gui 实例化之后立即创建一个 debugObject:
const gui = new GUI({ width: 340 })
const debugObject = {} 然后,就在 waterMaterial 实例化之前,我们可以把这两种颜色创建为 debugObject 的属性,并在两个新的 uniform 中使用它们,我们将其称为 uDepthColor 和 uSurfaceColor。这些颜色将使用 Color 类:
// Colors
debugObject.depthColor = '#0000ff'
debugObject.surfaceColor = '#8888ff'
// Material
const waterMaterial = new THREE.ShaderMaterial({
vertexShader: waterVertexShader,
fragmentShader: waterFragmentShader,
uniforms:
{
// ...
uDepthColor: { value: new THREE.Color(debugObject.depthColor) },
uSurfaceColor: { value: new THREE.Color(debugObject.surfaceColor) }
}
}) 然后我们可以用 addColor 方法把它们添加到我们的 lil-gui 中。我们还需要在颜色变化时用 onChange(...) 更新 waterMaterial 的 uniforms:
debugObject.depthColor = '#0000ff'
debugObject.surfaceColor = '#8888ff'
gui.addColor(debugObject, 'depthColor').onChange(() => { waterMaterial.uniforms.uDepthColor.value.set(debugObject.depthColor) })
gui.addColor(debugObject, 'surfaceColor').onChange(() => { waterMaterial.uniforms.uSurfaceColor.value.set(debugObject.surfaceColor) })
你应该能看到颜色 tweak,但改变它们并不会影响 material。这是因为我们还没有在 shader 中使用 uDepthColor 和 uSurfaceColor uniform。
在 fragment shader 中,首先获取这些颜色:
uniform vec3 uDepthColor;
uniform vec3 uSurfaceColor; 并只使用一种颜色来验证一切正常:
// ...
void main()
{
gl_FragColor = vec4(uDepthColor, 1.0);
#include <colorspace_fragment>
}
我们现在需要做的是:如果波浪较低就多用一些 uDepthColor,如果较高就多用一些 uSurfaceColor。
还记得上一课,我们将使用 mix(...)。这个函数需要一个第一个输入、一个第二个输入,以及一个用来决定如何混合前两个输入的值。
- 如果第三个值是
0.0,结果将是第一个输入。 - 如果第三个值是
1.0,结果将是第二个输入。 - 如果第三个值是
0.5,结果将是两个输入的完美混合。 - 如果第三个值低于
0.0或高于1.0,该值将被外推。
正如你能想象的,前两个参数是 uDepthColor 和 uSurfaceColor。但是控制混合的第三个值呢?
我们可以使用 elevation,但不幸的是这个变量在 vertex shader 中。
为了把这个变量传递到 fragment shader ——就像我们在之前的课程中所做的那样—— 我们将使用一个 varying。在 vertex shader 中,创建一个 vElevation varying 并在 main 函数中更新它:
// ...
varying float vElevation;
void main()
{
// ...
// Varyings
vElevation = elevation;
} 在 fragment shader 中获取这个 varying。然后创建一个 color 变量,根据 vElevation 混合 uDepthColor 和 uSurfaceColor:
uniform vec3 uDepthColor;
uniform vec3 uSurfaceColor;
varying float vElevation;
void main()
{
vec3 color = mix(uDepthColor, uSurfaceColor, vElevation);
gl_FragColor = vec4(color, 1.0);
#include <colorspace_fragment>
}
你应该能看到颜色有非常细微的变化。问题在于,根据我们的代码,我们的 vElevation 目前只从 - 0.2 变化到 + 0.2。我们需要找到一种方法来控制这个 vElevation,但只在 fragment shader 中。
让我们添加一些 uniforms!我们将创建一个 uColorOffset 和 uColorMultiplier,并把它们都添加到我们的 lil-gui 中:
const waterMaterial = new THREE.ShaderMaterial({
vertexShader: waterVertexShader,
fragmentShader: waterFragmentShader,
uniforms:
{
// ...
uColorOffset: { value: 0.25 },
uColorMultiplier: { value: 2 },
}
})
// ...
gui.add(waterMaterial.uniforms.uColorOffset, 'value').min(0).max(1).step(0.001).name('uColorOffset')
gui.add(waterMaterial.uniforms.uColorMultiplier, 'value').min(0).max(10).step(0.001).name('uColorMultiplier') 现在在 fragment shader 中获取 uColorOffset 和 uColorMultiplier uniform,基于这两个 uniform 创建一个 mixStrength 变量 ——以便更容易阅读—— 并在 mix(...) 函数中使用该变量:
uniform vec3 uDepthColor;
uniform vec3 uSurfaceColor;
uniform float uColorOffset;
uniform float uColorMultiplier;
varying float vElevation;
void main()
{
float mixStrength = (vElevation + uColorOffset) * uColorMultiplier;
vec3 color = mix(uDepthColor, uSurfaceColor, mixStrength);
gl_FragColor = vec4(color, 1.0);
#include <colorspace_fragment>
}
你获得了更好的渐变。调整这些值来调出你喜欢的颜色:
debugObject.depthColor = '#186691'
debugObject.surfaceColor = '#9bd8ff'
// ...
const waterMaterial = new THREE.ShaderMaterial({
vertexShader: waterVertexShader,
fragmentShader: waterFragmentShader,
uniforms:
{
// ...
uColorOffset: { value: 0.08 },
uColorMultiplier: { value: 5 }
}
})
小波浪
对于小波浪,我们将使用 perlin 噪声。我们在上一课中使用了 2D perlin 噪声,在那里我们发送 2D 坐标并返回一个 float 值。这一次,我们将使用 3D perlin。这将使该噪声能够随时间变化,从而得到更真实的结果。
前往与上一课相同的 gist,并复制 Stefan Gustavson 的 Classic Perlin 3D Noise:https://gist.github.com/patriciogonzalezvivo/670c22f3966e662d2f83
或者将下面的代码复制到 vertex shader 中:
// Classic Perlin 3D Noise
// by Stefan Gustavson
//
vec4 permute(vec4 x)
{
return mod(((x*34.0)+1.0)*x, 289.0);
}
vec4 taylorInvSqrt(vec4 r)
{
return 1.79284291400159 - 0.85373472095314 * r;
}
vec3 fade(vec3 t)
{
return t*t*t*(t*(t*6.0-15.0)+10.0);
}
float cnoise(vec3 P)
{
vec3 Pi0 = floor(P); // Integer part for indexing
vec3 Pi1 = Pi0 + vec3(1.0); // Integer part + 1
Pi0 = mod(Pi0, 289.0);
Pi1 = mod(Pi1, 289.0);
vec3 Pf0 = fract(P); // Fractional part for interpolation
vec3 Pf1 = Pf0 - vec3(1.0); // Fractional part - 1.0
vec4 ix = vec4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
vec4 iy = vec4(Pi0.yy, Pi1.yy);
vec4 iz0 = Pi0.zzzz;
vec4 iz1 = Pi1.zzzz;
vec4 ixy = permute(permute(ix) + iy);
vec4 ixy0 = permute(ixy + iz0);
vec4 ixy1 = permute(ixy + iz1);
vec4 gx0 = ixy0 / 7.0;
vec4 gy0 = fract(floor(gx0) / 7.0) - 0.5;
gx0 = fract(gx0);
vec4 gz0 = vec4(0.5) - abs(gx0) - abs(gy0);
vec4 sz0 = step(gz0, vec4(0.0));
gx0 -= sz0 * (step(0.0, gx0) - 0.5);
gy0 -= sz0 * (step(0.0, gy0) - 0.5);
vec4 gx1 = ixy1 / 7.0;
vec4 gy1 = fract(floor(gx1) / 7.0) - 0.5;
gx1 = fract(gx1);
vec4 gz1 = vec4(0.5) - abs(gx1) - abs(gy1);
vec4 sz1 = step(gz1, vec4(0.0));
gx1 -= sz1 * (step(0.0, gx1) - 0.5);
gy1 -= sz1 * (step(0.0, gy1) - 0.5);
vec3 g000 = vec3(gx0.x,gy0.x,gz0.x);
vec3 g100 = vec3(gx0.y,gy0.y,gz0.y);
vec3 g010 = vec3(gx0.z,gy0.z,gz0.z);
vec3 g110 = vec3(gx0.w,gy0.w,gz0.w);
vec3 g001 = vec3(gx1.x,gy1.x,gz1.x);
vec3 g101 = vec3(gx1.y,gy1.y,gz1.y);
vec3 g011 = vec3(gx1.z,gy1.z,gz1.z);
vec3 g111 = vec3(gx1.w,gy1.w,gz1.w);
vec4 norm0 = taylorInvSqrt(vec4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));
g000 *= norm0.x;
g010 *= norm0.y;
g100 *= norm0.z;
g110 *= norm0.w;
vec4 norm1 = taylorInvSqrt(vec4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));
g001 *= norm1.x;
g011 *= norm1.y;
g101 *= norm1.z;
g111 *= norm1.w;
float n000 = dot(g000, Pf0);
float n100 = dot(g100, vec3(Pf1.x, Pf0.yz));
float n010 = dot(g010, vec3(Pf0.x, Pf1.y, Pf0.z));
float n110 = dot(g110, vec3(Pf1.xy, Pf0.z));
float n001 = dot(g001, vec3(Pf0.xy, Pf1.z));
float n101 = dot(g101, vec3(Pf1.x, Pf0.y, Pf1.z));
float n011 = dot(g011, vec3(Pf0.x, Pf1.yz));
float n111 = dot(g111, Pf1);
vec3 fade_xyz = fade(Pf0);
vec4 n_z = mix(vec4(n000, n100, n010, n110), vec4(n001, n101, n011, n111), fade_xyz.z);
vec2 n_yz = mix(n_z.xy, n_z.zw, fade_xyz.y);
float n_xyz = mix(n_yz.x, n_yz.y, fade_xyz.x);
return 2.2 * n_xyz;
} 这次不需要做修复。
我们现在可以使用 cnoise 函数,并将 vec3 作为参数。
以下是 vec3 的三个值:
x将是modelPosition的xy将是modelPosition的zz将是uTime。第三个值将使噪声以自然且真实的方式演变。
elevation += cnoise(vec3(modelPosition.xz, uTime));
这不是预期的结果。首先,波浪速度太快了。所以你必须用 0.2 乘以 uTime:
elevation += cnoise(vec3(modelPosition.xz, uTime * 0.2));
其次,频率太小了。这导致波浪和我们之前创建的大波浪一样大。要增加频率,用 3.0 乘以 modelPosition.xz:
elevation += cnoise(vec3(modelPosition.xz * 3.0, uTime * 0.2));
第三,波浪太高了。让我们通过用 0.15 乘以噪声来减小它:
elevation += cnoise(vec3(modelPosition.xz * 3.0, uTime * 0.2)) * 0.15;
最后,现实中的波浪并没有那么平滑。真实的波浪有圆润的波谷和高耸的波峰。为了实现这个结果,我们可以使用 abs(...) 函数:
elevation += abs(cnoise(vec3(modelPosition.xz * 3.0, uTime * 0.2)) * 0.15);
我们得到的正好与想要的相反,成了圆润的波峰和高耸的波谷。要反转波浪,把 + 替换为 -:
elevation -= abs(cnoise(vec3(modelPosition.xz * 3.0, uTime * 0.2)) * 0.15);
这好多了,但当你观察汹涌海面上的波浪时,它们看起来更加混乱,有着不同且难以预测的频率。
我们需要在更高的频率上应用更多的噪声。我们可以用不同的值重复前一行,但这是使用 for(...) 循环的绝佳机会。
for(...) 循环在 GLSL 中是可以使用的。只要确保使用 float 类型的变量。我们将从 1.0 开始进行 3 次迭代:
for(float i = 1.0; i <= 3.0; i++)
{
} 然后把我们之前的公式移入循环中:
for(float i = 1.0; i <= 3.0; i++)
{
elevation -= abs(cnoise(vec3(modelPosition.xz * 3.0, uTime * 0.2)) * 0.15);
}
现在,我们应用了 3 次相同的公式,这应该产生相同的波浪,但它们的振幅要明显得多。
让我们根据 i 变量增加频率并减小振幅:
for(float i = 1.0; i <= 3.0; i++)
{
elevation -= abs(cnoise(vec3(modelPosition.xz * 3.0 * i, uTime * 0.2)) * 0.15 / i);
}
这好多了。也许你没有注意到,但我们几乎看不到那些较小的波浪。这是因为我们的 geometry 缺少顶点。把细分增加到 512x512:
const waterGeometry = new THREE.PlaneGeometry(2, 2, 512, 512)
那代表了很多三角形,但 plane 是 scene 中唯一的 geometry,而且我们几乎所有的动画都在 shader 中完成,这意味着 GPU 承担了繁重的工作。
让我们添加一些 uniform 和 tweak 来控制这些小波浪:
const waterMaterial = new THREE.ShaderMaterial({
vertexShader: waterVertexShader,
fragmentShader: waterFragmentShader,
uniforms:
{
// ...
uSmallWavesElevation: { value: 0.15 },
uSmallWavesFrequency: { value: 3 },
uSmallWavesSpeed: { value: 0.2 },
uSmallIterations: { value: 4 },
// ...
}
})
// ...
gui.add(waterMaterial.uniforms.uSmallWavesElevation, 'value').min(0).max(1).step(0.001).name('uSmallWavesElevation')
gui.add(waterMaterial.uniforms.uSmallWavesFrequency, 'value').min(0).max(30).step(0.001).name('uSmallWavesFrequency')
gui.add(waterMaterial.uniforms.uSmallWavesSpeed, 'value').min(0).max(4).step(0.001).name('uSmallWavesSpeed')
gui.add(waterMaterial.uniforms.uSmallIterations, 'value').min(0).max(5).step(1).name('uSmallIterations') 在 vertex shader 中:
uniform float uSmallWavesElevation;
uniform float uSmallWavesFrequency;
uniform float uSmallWavesSpeed;
uniform float uSmallIterations;
// ...
void main()
{
// ...
for(float i = 1.0; i <= uSmallIterations; i++)
{
elevation -= abs(cnoise(vec3(modelPosition.xz * uSmallWavesFrequency * i, uTime * uSmallWavesSpeed)) * uSmallWavesElevation / i);
}
// ...
}
就是这样。
进阶
如果你想更进一步,可以尝试添加泡沫。
你也可以尝试扩大 plane。
添加 fog 将是一个获得更加沉浸式体验的好方法,能增强那种迷失在海中的感觉。不过要小心,添加 fog 会比我们之前看到的更具挑战性,因为你需要自己编写 fog 的代码。